Skip to content

feat(rag): on-device with citations - #239

Draft
kfaracik wants to merge 46 commits into
mainfrom
feat/221-rag-sources
Draft

feat(rag): on-device with citations#239
kfaracik wants to merge 46 commits into
mainfrom
feat/221-rag-sources

Conversation

@kfaracik

@kfaracik kfaracik commented Jul 7, 2026

Copy link
Copy Markdown
Member

Resolves #221.

Adds retrieval-augmented generation over user documents, fully on-device. A user attaches PDFs/text, the app embeds and indexes them, and each reply is grounded in — and cited back to — the passages it actually used. No content leaves the device.

This is the baseline: vector retrieval end to end. The retrieval-quality layer (keyword/BM25 fusion, MMR, adaptive-k, multilingual refinements) is a stacked follow-up — see feat/rag-hybrid — so this PR stays reviewable on its own.

What's in it

  • Embedding + indexing — on-demand download of the LFM 2.5 350M embedding model (~415 MB, not bundled), text extraction + chunking, and an op-sqlite vector index. Chunks carry deterministic ids (documentId:chunkIndex). Extracted text is capped (MAX_SOURCE_TEXT_CHARS) before chunking so a pathological multi-MB document can't blow up the chunk array and exhaust memory; the result is flagged truncated for the UI.
  • Vector retrieval (utils/retrieval.ts) — semantic search over the enabled sources, capped per document, ordered attachment-first, then expanded with neighboring chunks so a matched passage arrives with its surrounding context. The semantic-similarity floor is calibrated on-device (0.40; true paraphrase matches land ~0.28–0.54) and the single best candidate always survives above a 0.25 top-keep floor, so a paraphrase query never silently returns nothing.
  • Context assembly (utils/contextUtils.ts) — groups chunks per document into Source N blocks and stitches adjacent passages, de-duplicating overlap.
  • Citations — after generation each reply is attributed back to the source(s) it was actually based on (answer↔passage term overlap) and those passages are highlighted; refusals cite nothing.
  • Lifecyclecontext/VectorStoreContext.tsx owns store init/teardown (idempotent, abortable on unmount).

⚠️ Migration: previously imported sources

Two migrations run on launch (database/db.ts, database/vectorStoreMigration.ts, utils/embeddingModelMigration.ts):

  • Schema — additive ALTER TABLE columns onto older DBs; non-transactional but idempotent-retry-safe (covered by __tests__/dbSchemaMigration.test.ts).
  • Source incompatibility — the legacy pre-RAG vectors table is dropped, and changing the embedding model wipes previously imported sources (their embeddings are model-specific): sources + chatSources are cleared and the user re-imports. This is deliberate — old embeddings can't be reused across models — and is the one user-visible breaking behavior in this PR. The wipe fires only on a genuine model change: a missing model key (e.g. cleared AsyncStorage on a populated store) adopts the current model without deleting, so a lost key never silently destroys the user's imported sources.

The app ↔ library boundary

The app delegates the "boring" layer (embeddings runtime, vector store, splitters) to react-native-rag and owns only deliberate extensions:

  1. Asymmetric Q/D prefixes (query: / document: ) — required by LFM 2.5.
  2. Deterministic chunk ids (documentId:chunkIndex) — make neighbor expansion pure id arithmetic and let a future keyword index join without a mapping table.
  3. Historical migrations — schema/embedding-model migrations the library doesn't own.

Retriever (bottom of retrieval.ts) is a thin wrapper binding store + embeddings into a single retrieve(query, options) call.

Key decisions / trade-offs

  • Model downloaded on demand, not bundled. Keeps the app installable; the ~415 MB is fetched on first RAG use.
  • Retrieval constants live in constants/retrieval.ts with per-value rationale (where the number comes from, what moving it does).
  • Citation attribution is post-hoc. The model does not emit [n] markers; pickCitationsByAnswer infers which sources were used from answer↔passage term overlap. A heuristic, not ground truth.

Known limitations

  • hydrateChunksByIds reads op-sqlite's internal schema. Raw SQL against the library's vectors table (a get-by-ids the public API lacks) — fast, but coupled to library internals; revisit on op-sqlite bumps.
  • No-answer detection is EN-only here. Polish refusal detection ships in the stacked hybrid PR.
  • Retrieval is vector-only. Exact-match recall (names, codes, rare tokens) that embeddings miss is recovered in the hybrid follow-up.

How to test locally

  1. First-run download — trigger RAG for the first time; verify the embedding model download progress UI and cancel/resume (needs Wi-Fi + ~415 MB free).
  2. Single doc — new chat → attach a PDF → send the first message. Expect a grounded answer with cited, highlighted sources and no FK error.
  3. Multi-doc — enable several sources and ask a spanning question; the answer should draw from more than one and citations should match.
  4. Off-topic guard — ask something the docs don't cover; expect a no-answer reply and no spurious citations.
  5. Migration — open an app built before RAG with existing chats/sources; verify the legacy notice and that nothing crashes on launch.
  6. Large / oversized doc — attach a very large text file; it indexes without an out-of-memory crash and is flagged truncated.
  7. Suiteyarn jest (493 tests) and yarn lint.

Notes for the reviewer

  • Suggested review order: indexing (store/sourceStore.ts, hooks/useAttachment.ts) → retrieval (utils/retrieval.ts, utils/contextUtils.ts) → citations (utils/messageSources.ts, utils/citationHighlight.ts) → chat wiring.
  • feat/rag-hybrid is stacked on this branch and should merge after it.

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
private-mind Error Error Jul 23, 2026 8:46am

Request Review

@kfaracik

Copy link
Copy Markdown
Member Author

RAG improvements — analysis, research & backlog

Analysis of the local-first RAG pipeline on feat/221-rag-sources (attach files → embed on-device → retrieve → cite as sources), a research pass over 2023–2026 literature and the on-device RN/ExecuTorch ecosystem, and a prioritized backlog. The last section tracks what has already been implemented.

Scope of the app this targets: fully on-device (mid-range phones), ExecuTorch .pte models, small LLM (1–4B, ~2048-token context, weak instruction-following), LFM2.5-Embedding-350M (1024-dim), personal-scale corpus (typically 1–20 documents / hundreds–thousands of chunks), Polish + English are first-class.

Evidence caveat: quantitative claims below come from a single research pass (papers were fetched and quoted, but the adversarial cross-check stage did not complete). Treat numbers as directional and validate on a small in-house PL+EN eval set before hard-coding thresholds.


1. What the pipeline does today

Stage Implementation
Ingestion PDF (react-native-pdfium → flat text, no page numbers), txt/md/csv/html/xlsx. No docx, no OCR.
Chunking RecursiveCharacterTextSplitter, 1000 chars / 200 overlap. Chunk metadata: {documentId, name, chunkIndex, isFirstChunk} only.
Embedding LFM2.5-Embedding-350M, 1024-dim fp32 BLOB, query:/document: prefixes, on-demand download (~415 MB). Model-version wipe on switch.
Storage op-sqlite: vectors (brute-force cosine) + chunk_fts FTS5 (unicode61 remove_diacritics 2, hand-folded ł→l). Plaintext.
Retrieval Hybrid vector + FTS5 BM25, top-20 each → RRF (k=60, 1:1) → relevance gates → MMR (λ=0.7) → per-file capadaptive-krelevance-first neighbor windows (chunkIndex±1) → final chunks.
Prompt Trimmed grounding instructions (~770 chars); always-on grounding cue on the user turn; context ordered most-relevant-first and truncated at chunk boundaries; sources as --- Source N: name ---; similarity not injected; history trimmed to a char budget.
Citations Model [n] markers stripped; highlight re-derived from query terms. Answer-time attribution: cite only sources whose Source N block survived budget truncation → then only those the visible reply (<think> stripped) lexically echoes (≥ ratio of the top) → a meta-refusal ("no information about X") cites nothing. Fresh attachments always kept.
Delete Solid cascade: sourceschatSources (CASCADE) → vector predicate delete → FTS5 delete + event message.

Verdict: the retrieval core is strong and in places ahead of common practice (RRF over weighted-sum, MMR, term-coverage boost, Polish diacritics + stem-prefix, neighbor expansion). The weak 10% was the tail: final-set selection, budget packing, and answer-time attribution. Most of that is now addressed (§5) — final-set selection (adaptive-k, per-file cap), budget packing (boundary truncation, leaner instructions), and answer-time attribution (truncation-honest → answer-echo → refusal suppression). Remaining tail work: short-code recall and context de-duplication (§2 A11/A12).


2. Bugs & tensions found in the code (verified with file:line)

These are defects/mismatches, not enhancements — fix first.

  • A1 — Prompt budget fights retrieval. Budget = (2048−512)×3 = 4608 chars for the whole prompt (constants/context-window.ts). Retrieval + neighbor expansion + attachment overview + ~1750 chars of appended instructions routinely exceeded it, and utils/promptUtils.ts hard-slice()d the joined context mid-chunk, dropping trailing Source N blocks. ✅ Mitigated (§5): context is ordered most-relevant-first and truncated at a chunk/Source boundary (never mid-passage), and the appended instructions were cut 1750→770 chars — together keeping the answer inside budget. System prompt and question are still never trimmed; the attachment overview is still prepended unconditionally (see §4 [Feature] Implement figma designs #9).
  • A2 — Every model treated as 2048 tokens. CONTEXT_WINDOW_TOKENS_BY_FAMILY is {} (constants/context-window.ts:9); the per-family override mechanism exists but is empty. If any .pte export supports 4096+, that budget is left on the table. (Deferred: needs per-model max-seq-len confirmation.)
  • A3 — queryText fallback is dead code and swallows keyword search. When the embedder is unavailable, vectorStore.query({queryText}) calls the same embedder and throws; the rejection is inside Promise.all with keywordSearch, so FTS5 results are discarded too and retrieval silently returns []. ✅ Fixed (§5).
  • A4 — A fresh attachment fully evicts enabled sources. ATTACHMENT_RELEVANCE_BONUS=+10 guarantees attachment chunks outrank everything; with MAX_RELEVANT_CHUNKS=5 a ≥5-chunk attachment takes all slots, starving other enabled documents ("compare this to last week's contract" can't work). ✅ Mitigated by the per-file cap (§5).
  • A5 — Scanned PDF shows "Document appears to be empty." (hooks/useAttachment.ts:203-213) — misleading; the doc isn't empty, it has no text layer. ✅ Fixed (§5).
  • A6 — Multi-file attach is structurally impossible though downstream (attachmentSourceIds[], join(', '), prompt) is written for many. Picker lacks multiple:true; setAttachments([...]) replaces the array. (Deferred — see §6.)
  • A7 — Source-management UI is dead code. renameSource/deleteSource have no UI caller; a privacy-first app currently can't delete an indexed document. No per-chat sources panel, no disableSource. (Deferred — see §6.)
  • A8 — Small-model over-abstention on bare queries (behavioral, from device logs). A 1.7B model under-weights the distant grounding system prompt: a bare question — especially cross-lingual (Polish query over an English doc) — returns "I don't know" even when the answer sits in the retrieved <context>. Typing "według załączonego dokumentu" / "according to the attached document" flips it to a correct answer, confirming grounding, not retrieval, is the blocker — verified with two logs of the same question where the answer chunk led the context in both, yet only the cued one was used. ✅ Mitigated (§5): an always-on grounding cue on the user turn, emitted in the query's language (Polish for a Polish query) and placed after the question, mirroring the proven manual phrase. ⚠️ Confirm on-device that a bare Polish question now answers without the manual "według dokumentu".
  • A9 — Citations came from the pre-truncation retrieved set (from device logs). sourceDocuments was built from every retrieved chunk before promptUtils.ts truncated the joined context to budget, so a document dropped to fit the window — or merely co-retrieved from a chat's accumulated enabledSources — was still cited beside the answer ("attach file X, get files Y and Z cited"). Root enabler: every attached file is permanently added to enabledSources (ChatScreen.tsx:223-227) and the chip clears after send (ChatBar.tsx:181), so later turns retrieve across the whole pile. ✅ Fixed (§5): restrictCitationsToContext keeps only sources whose Source N block survived truncation, then pickCitationsByAnswer keeps only those the visible reply echoes.
  • A10 — A refusal still cited the sources it described (from device logs). A verbose "no information about L4 in these documents" reply describes both docs to justify the absence, so it lexically overlaps them and slipped past overlap attribution → cited as if grounded. Stripping <think> was not enough (the visible reply itself overlaps). ✅ Fixed (§5): a meta-refusal detector (looksLikeNoAnswer) suppresses all non-attachment citations. Deliberately narrow — a negative-fact answer ("nie ma limitu", "no debt", "does not mention costs") is not a refusal and keeps its citation.
  • A11 — Short alphanumeric codes dropped by the tokenizer (from device logs). extractQueryTerms drops tokens < 3 chars (queryTerms.ts:96), so "L4"/"K2"/"E-42" never become query or attribution terms — a document that does contain the code is neither surfaced by keyword search nor attributed to the answer. ⏳ open (§4): pass 2-char letter+digit tokens (codes) while still dropping bare numbers/short words; FTS5 indexing of such codes still to verify.
  • A12 — An attachment appears twice in context, confusing the small model (from device logs). buildMessageSources prepends the attachment's overview (Current Attachment Source: … (Overview)) and the same file appears as a retrieved Source N (attachments always clear the gate via the bonus). The 1.7B model then miscounts blocks and reads a --- End of Source N --- closing marker as a phantom source's content. Citations stayed correct, but budget is wasted and the answer's reasoning is muddled. ⏳ open (§4 [Feature] Implement figma designs #9): drop the overview for any attachment that already has a retrieved Source block.

3. Anti-recommendations (deliberately NOT doing — saves weeks)

Technique Why not (at this scale/model size)
HyDE / multi-query / LLM query expansion 1.3B generator recovers +0.8 of a +15.0 nDCG gain available to 175B (Query2doc, EMNLP 2023); on Gemma 1B/4B HyDE adds 25–43% latency (arXiv:2506.21568). Fix the retriever instead.
Semantic / LLM chunking, RAPTOR, DenseX NAACL 2025 Findings: cost not justified; recursive splitter is at/near optimum incl. Polish (PoQuAD 89.4% vs 87.7%).
Late chunking (Jina) ExecuTorch pools inside the runtime and doesn't expose token embeddings — needs model re-export + native API change.
Constrained/grammar decoding for citations react-native-executorch exposes no logit hooks; post-hoc attribution beats generation-time on coverage anyway.
"According to the sources…" prompt incantations Flat-to-worse below 11B (EACL 2024). Spend the chars on source text.
Always-on semantic-entropy / SelfCheckGPT 3–10 extra generations per answer; smallest validated generator is 7B. At most an on-demand "double-check" button.
ANN / HNSW / EdgeRAG indexing Flat brute-force wins while the index fits in RAM (EdgeRAG's own baselines); this corpus fits with huge margin, more so after int8.
Asking a 1–4B model to self-cite [n] ALCE (EMNLP 2023): sub-13B citation quality is poor. Keep stripping markers; attribute post-hoc.

4. Prioritized backlog

Priority 1 — high-evidence, code-only, no new deps/models/migrations

  1. Adaptive-k — stop always sending 5 chunks; cut at the largest gap in raw relevance, 1–5. One related distractor cuts small-model accuracy up to 25% (Power of Noise, SIGIR 2024, incl. Phi-2); quality vs k is an inverted-U (ICLR 2025). ✅ implemented (conservative).
  2. Per-file cap + attachment cap — max chunks per document when ≥2 docs are enabled; stops one long doc / one fresh attachment monopolizing the context. ✅ implemented.
  3. Prompt reordering — put the best chunk adjacent to the question (worst position today). Lost in the Middle (TACL 2024); replicated at 2.7B. ✅ implemented — expandSelectedWithNeighbors emits relevance-ranked windows (the best seed's [prev, seed, next] first), so a budget truncation drops the least-relevant tail rather than the answer. Source↔citation alignment held: getSourceDocumentsFromChunks groups the same chunks.
  4. Follow-up query contextualization (tier-1, no LLM) — under-specified queries ("a ile to kosztowało?") collapse below the gate; concatenate prior turn + last-answer terms into the retrieval query only. Biggest real multi-turn failure mode. ⏳ deferred (needs call-site history threading).
  5. Overlap-dedup at stitching — neighbor expansion re-duplicates the 200-char overlap in the prompt; strip it. ✅ implemented.
  6. Contextual chunk headers from metadata (no LLM) — prepend Dokument: {name} | Strona {n} | Sekcja: {…} before embedding + FTS; the file name is invisible to both retrievers today. Anthropic Contextual Retrieval −49% failure rate; heading-breadcrumb variant gets most of it for free. ⏳ deferred (re-index migration).
  7. FTS5 for Polish — emit ("kosztowało" OR "kosztowa"*) instead of replacing exact form with prefix; add a phrase-match retriever; declare prefix='4 5 6'. BEIR-PL: BM25 degrades on Polish inflection. ⏳ deferred (re-index).
  8. Retrieval status UX — after send, the typed text vanishes and nothing shows during retrieval; render "Przeszukuję dokumenty…". ⏳ deferred (UI/store change).
  9. Conditional attachment overview (fixes A12) — the cover-page first chunk is prepended for every attachment turn (messageSources.ts), spending ~1000 chars of budget on boilerplate even for pointed factual questions, and duplicating a file that also appears as a retrieved Source N — which confuses the small model into a phantom source (A12). Inject the overview only when retrieval surfaced none of the attachment's own chunks (broad / "summarize this" fallback), and trail the retrieved chunks so truncation drops it first. ⏳ open (needs care: the overview is the only signal when a broad query retrieves nothing).
  10. Short-code tokenization (fixes A11) — let extractQueryTerms keep 2-char letter+digit tokens ("L4", "K2", "3D") while still dropping bare numbers and short words, so a code the user asks about drives keyword search + attribution. Shared tokenizer → also improves the citation highlight. ⏳ open (verify FTS5 indexes such codes; keep single-file precision).

Priority 2 — medium cost, high value

  • Post-hoc citation attribution (CiteFix-style) — ✅ implemented (§5), keyword/term-overlap variant: cite a source only if the visible reply echoes its passage terms (≥ ratio of the strongest), with <think> stripped and a meta-refusal citing nothing. CiteFix (ACL 2025 Industry): +15.5% citation accuracy, 15 ms. Still open: the cosine blend (~0.8·keyword + 0.2·cosine, chunk embeddings already in memory) and the answer-derived highlight + tappable chipsSourcesSheet.present(index) (the index param already exists and is unused).
  • Zero-model groundedness badge — stopword-filtered answer↔context token overlap (K-Precision). TACL 2024: Spearman ~0.50, beats NLI (~0.29) and BERTScore (~0.23); do NOT build it on embedding cosine. Soft badge only.
  • Pre-generation abstention — when the gate passes nothing, skip the LLM, show "Nie znalazłem tego w dokumentach" + "Answer without documents". Small models can't follow "say I don't know" (Sufficient Context, ICLR 2025).
  • Per-query gate normalization — replace the fixed cosine ≥ 0.55 with a percentile/z-score cut. Cosine scores aren't comparable across queries (Cosine Adapter, CIKM 2024); 0.55 likely over-filters Polish (not among LFM2.5's 11 languages). Needs a small eval set.
  • Page-aware PDF — the vendored pdfium extractText() already loops per page; emit a page delimiter → pageStart in metadata → "p. 12" citations + section headers from bookmarks.
  • Multi-file attachmultiple:true, append not replace, sequential ingest queue, cap ~5.
  • DOCX via mammoth.js browser build ({arrayBuffer} from Expo File API, convertToHtml→markdown).
  • Resumable ingestion — insert all chunks (embedding=NULL) first, embed via an idempotent WHERE embedding IS NULL cursor; survives app kill.
  • int8 embedding quantization (+ optional MRL→512) — ~99% nDCG retained at 4× storage; LFM2.5 is Matryoshka-trained. Format migration, no re-embed.
  • Per-chat sources panel + library reuse — wire the existing enabledSources backend to toggles + "add from library" (vectors already exist → zero re-embed) + a global rename/delete screen.

Priority 3 — roadmap

  • ONNX cross-encoder reranker routed by language (PL: sdadas/polish-reranker-base-ranknet ~124M; EN: ms-marco-MiniLM 22M) via onnxruntime-react-native, behind a setting.
  • On-device OCR (ML Kit v2 Latin covers Polish; Apple Vision does not support Polish) for scanned PDFs/images.
  • On-device grounding verifier (LettuceDetect EuroBERT-210M or CiteVerifier 355M — both cover Polish; skip generic mDeBERTa-NLI).
  • KV prefix caching of the static instruction prefix once the runtime exposes it (Prompt Cache, MLSys 2024: up to 60× TTFT on CPU).

5. Implemented in this pass

Code-only, no new dependencies, no native changes, no re-index/data migration. All covered by unit tests.

Item Change Files
A3 keyword-only fallback Vector query runs with its own .catch(() => []), so an unavailable embedder degrades to keyword-only retrieval instead of returning nothing. utils/hybridRetrieval.ts
B2/A4 per-file cap maximalMarginalRelevance gained an optional per-group cap; hybrid retrieval caps chunks per document (MAX_CHUNKS_PER_FILE) when ≥2 documents qualify, so a fresh attachment can no longer evict every enabled source. utils/rankFusion.ts, utils/hybridRetrieval.ts, constants/retrieval.ts
B1 adaptive-k After MMR, trailing low-relevance chunks are dropped at the first large relative gap (ADAPTIVE_K_DROP_RATIO), keeping ≥1 non-attachment chunk and all attachments. utils/rankFusion.ts, utils/hybridRetrieval.ts, constants/retrieval.ts
B5-stitch overlap dedup Adjacent passages in a source group are stitched with their duplicated splitter overlap removed, saving prompt budget and giving the model continuous text. utils/contextUtils.ts
A5 scanned-PDF message An empty-text PDF returns reason: 'scanned_pdf'; the attachment toast explains it looks scanned and has no selectable text. store/sourceStore.ts, hooks/useAttachment.ts
B3 prompt reordering After MMR, each seed's neighbor window is emitted most- to least-relevant so the matched chunk leads the stitched passage (Lost-in-the-Middle) and survives budget truncation. utils/hybridRetrieval.ts
A1 boundary truncation Over-budget context is cut at the last \n\n / --- Source boundary — a whole least-relevant trailing section is dropped instead of slicing mid-passage; the drop is logged (keptChars/droppedChars). utils/promptUtils.ts
A1 leaner instructions CONTEXT_INSTRUCTION + attachment-priority text cut 1750→770 chars, reclaiming ~22% of the 4608-char budget for source text (terse rules also suit a small model). utils/promptUtils.ts
A8 grounding cue (lang-aware) A grounding cue is appended to every context turn — in the query's language (PL for a PL query) and after the question, mirroring the proven "według dokumentu" workaround. Fixes small-model over-abstention where the answer led the context but was ignored. ⚠️ confirm bare PL on-device. utils/promptUtils.ts
A9 truncation-honest citations restrictCitationsToContext keeps only sources whose Source N/(Overview) header survived truncation into the actual prompt (parsed via sourcesPresentInContext); a document dropped to fit the budget is never cited. Fresh attachments exempt; never returns empty when citations existed. utils/messageSources.ts, utils/contextUtils.ts, store/llmStore.ts
A9/CiteFix answer attribution pickCitationsByAnswer scores each surviving source by how many of its passage terms the visible reply echoes (shared tokenizer, PL+EN stem-prefix) and keeps those within ANSWER_CITATION_OVERLAP_RATIO of the top. Fixes "attach X → cite Y & Z" when several small docs all fit the window. utils/messageSources.ts, constants/retrieval.ts, store/llmStore.ts
A10 <think> strip + refusal Attribution runs on visibleAnswer (the <think> block removed, so the reasoning's survey of every doc can't inflate overlap). A narrow meta-refusal detector (looksLikeNoAnswer, PL+EN coverage-negations only) suppresses non-attachment citations on a "no information about X" reply, while negative-fact answers keep theirs. utils/messageSources.ts, store/llmStore.ts

Compact hot-path logs (RAG retrieved / RAG prepared / LLM done with per-doc attribution, noAnswer, and a short visible-reply preview) make citation decisions diagnosable from the terminal without dumping document text.

Tuning constants live in constants/retrieval.ts (MAX_CHUNKS_PER_FILE, ADAPTIVE_K_DROP_RATIO, ADAPTIVE_K_MIN_KEEP, ANSWER_CITATION_OVERLAP_RATIO) so the above can be tuned once an eval set exists.

@kfaracik
kfaracik force-pushed the feat/221-rag-sources branch from 7cb456e to c866f06 Compare July 16, 2026 10:36
@kfaracik kfaracik changed the title Feat: document-grounded chat (RAG) with hybrid retrieval and inline citations # feat(rag): on-device hybrid retrieval with citations Jul 16, 2026
kfaracik and others added 13 commits July 16, 2026 15:49
Add a hybrid retriever that fuses semantic vector search with exact
keyword search and re-ranks the result on-device, replacing the plain
vector-only path. No extra ML model is loaded — fusion and re-ranking
are pure arithmetic.

- keywordIndex: FTS5/BM25 index mirroring the vector-store chunks in the
  same op-sqlite DB; degrades to a no-op when FTS5 is absent in the build
- rankFusion: Reciprocal Rank Fusion, cosine similarity, term coverage
  and Maximal Marginal Relevance primitives
- hybridRetrieve: run vector + keyword search concurrently, hydrate
  keyword-only hits, fuse, gate out noise-floor filler, MMR-diversify,
  and float freshly-attached sources to the front (deterministic
  "Source N" / citation order)
- Polish morphology: stem-prefix matching so "pliku" finds "plików",
  and manual ł/Ł folding the tokenizer's remove_diacritics misses
- retrieval/keyword-index constants extracted to constants/
- VectorStoreContext: serialize init/teardown across effect runs, build
  the keyword index eagerly, lazy-load the embedding model, log unload
  failures

Covered by unit tests for queryTerms, keywordIndex, rankFusion,
hybridRetrieve and the context formatters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kfaracik kfaracik self-assigned this Jul 20, 2026
@kfaracik kfaracik added the enhancement New feature or request label Jul 20, 2026
Bring in the scroll-down-button overlap fix (#251). Resolve the ChatScreen
import conflict: keep the expo-router import and adopt scroll-down's
useReanimatedKeyboardAnimation, dropping the now-unused KeyboardStickyView.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four files needed manual resolution, all of them semantic rather than
textual conflicts:

- promptUtils: both sides added a fifth parameter. Kept main's ordering
  (customSystemPrompt fifth, preferredSourceDocuments sixth) so the
  signature other branches were written against stays stable.
- llmStore: main moved prepareMessagesForLLM below waitForModelLoad and
  behind waitForSettingsHydration (#240's cold-start fix). Kept that
  placement and moved this branch's citation restriction after the call
  instead of hoisting the prompt build back up, which would have
  reintroduced the unhydrated-prompt race.
- ChatScreen: kept this branch's deferred navigation and buildSources
  callback. d956ad3 dropped setActiveChatId here on purpose — the chat id
  is passed to sendChatMessage explicitly and the route sets the active
  chat on mount — so main's re-added destructuring is dropped too.
- useAttachment: kept this branch's clearAll, which is main's plus the
  in-flight embedding abort.

Also realigned two llmStore tests to the buildSources callback signature.
kfaracik added 5 commits July 21, 2026 13:33
CONTEXT_WINDOW_TOKENS_BY_FAMILY was an empty map, so every model fell
through to the 2048 default and the prompt budget was a flat 4608 chars
regardless of the model. That truncates retrieved context far earlier
than any shipped model requires.

The numbers are deliberately conservative rather than the upstream ones:
the window is baked into the ExecuTorch export, not the base model, and
nothing exposes it at runtime, so overshooting would overflow. Unknown
and imported models keep the old default.
clearImportedSources swallowed a failing DELETE, and the caller stored
the new model id anyway. The vectors were already gone at that point, so
a partial failure left source rows pointing at nothing and, because the
key had advanced, no later launch would retry.

It now reports whether the wipe completed and the key is only written on
success. Both delete paths are idempotent, so the retry is safe.
Citation overlap counted every term in the reply, so "the report does not
mention revenue" shared "revenue" with the revenue passage and cited it —
as support for the opposite of what the reply said. looksLikeNoAnswer only
catches whole-reply refusals, not a negated clause inside an answer.

Terms are now taken from the asserted clauses only, so "covers X but does
not mention Y" still cites X. English cues only; the Polish side of
refusal detection is handled separately on feat/rag-hybrid.
Neighbor windows were emitted in seed-similarity order, so one document
could reach the model as chunks 9,10,11 followed by 2,3,4. The passage
stitcher also assumes adjacency when it dedups overlapping text.

Relevance still decides which chunks are selected and how documents are
ranked against each other; only the order within a document changes.

Note: feat/rag-hybrid replaces this file with utils/hybridRetrieval.ts,
which carries a verbatim copy of expandSelectedWithNeighbors. That copy
needs the same change or this fix disappears when the branch lands.
A document is embedded as a source the moment it is attached, but it is
only tied to a chat on send, so abandoning one left the source behind
forever. cleanupOrphanedSources existed but was unreachable: 822bf9b
flipped the cleanupSources default to false, both remaining callers pass
false explicitly, and the only caller relying on the default sits in a
ChatBar handle nothing invokes.

Cleanup now runs where a source is actually abandoned — removing an
embedded attachment, and unmounting with one still in the composer. The
send paths keep passing false, since enableSource is about to link the
source to the chat. The handle's call is explicit now too, so no caller
depends on the default.
@kfaracik kfaracik changed the title # feat(rag): on-device with citations feat(rag): on-device with citations Jul 21, 2026
The merge resolution reformatted this union with a locally stale prettier
3.8.3, undoing 08f66da and failing lint on CI, which installs the 3.9.4
from the lockfile. Restores the single-line form 3.9.4 produces.
kfaracik added 2 commits July 21, 2026 13:49
visibleAnswer cut at the first <think> and resumed after the first
</think>, so a second reasoning block landed in the text treated as the
visible reply. Citation scoring runs on that text, which meant hidden
reasoning could decide which sources got cited.

An unterminated block still ends the visible reply, since the model is
mid-thought and nothing after it has been said yet.
estimateTokens had no callers. The imperative clear() had none either —
it was the only caller left relying on clearAll's cleanupSources default,
which is why abandoned sources looked like they were being swept when
nothing invoked the path. setInput stays; it is used for prompt
suggestions.
kfaracik added a commit that referenced this pull request Jul 21, 2026
Brings the base branch's review fixes onto the hybrid layer. Git detected
utils/retrieval.ts -> utils/hybridRetrieval.ts as a rename, so most of it
carried over on its own. Three things needed a decision:

- embeddingModelMigration: this branch still had the pre-BLOCKER version
  with no dimension check. Took the base branch's version and moved
  dropKeywordIndex into clearImportedSources so the FTS index is dropped
  on every wipe path, not just the model-change one.
- Reverted ff23ebc's neighbor ordering. It sorted each document's chunks
  into reading order, which this branch tests against on purpose: context
  is truncated from the tail, so a 10-K's table of contents at chunk 2
  would survive while the answer at chunk 20 got cut. Windows are already
  emitted in ascending order internally; only their relative order is
  relevance-driven, and that is the right call.
- Dropped __tests__/retrieval.test.ts, whose module no longer exists here.

ff23ebc should be reverted on #239 for the same reason.
Reconciles the RAG sources/citations stack with the conversation
forking feature (#227): MessageItem carries both the Sources button
and the Copy/Fork action row, Messages renders branch markers next to
RAG-annotated messages, and the completed assistant message in
llmStore now keeps its persisted id together with the cited sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
performance.now is Date.now under the RN jest preset (1 ms resolution),
so on a fast CI machine the benchmark start and the first token could
land in the same millisecond, collapsing timeToFirstToken to 0 and
flaking the run. Drive the measurement with a virtual monotonic clock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kfaracik kfaracik added this to the RAG milestone Jul 23, 2026
@kfaracik
kfaracik marked this pull request as draft July 31, 2026 11:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Rag] Improve the quality

1 participant